Younix's Studio.

C 函数调用的时候栈发生了什么

字数统计: 2.3k阅读时长: 8 min
2016/01/01 Share

[TOC]

本文分析的问题是函数的栈调用机理。
先说结论

结论

  • 通过栈传递参数
  • 从右向左 参数压栈
  • 先压参数入栈
  • 然后返回地址入栈
  • ebp 等寄存器入栈
  • 调用过程中的栈是由调用方来维护

所谓的寄存器入栈 实际上是指的一组寄存器入栈。
因为在新调用的函数中,这些寄存器仍然会被用到,
为了退出调用函数后能恢复状态,凡是有可能被修改的寄存器都要入栈。
出栈顺序和入栈顺序相反。这个过程由编译器维护。

在现在普遍应用的单指令流,单数据流计算机上,编译后的程序都是基于栈来调度的。
程序装载入内存后,
代码指令映射到内存空间的指令区
操作的数据则在对应的栈空间和堆空间上。
堆空间用于动态内存的分配、应用。本文分析暂不考虑分析堆的问题。

错误的小例子

我们以如下例子为例

1
2
3
4
5
6
7
8
9
10
11
12
13
char* get_memory()
{
char p[]="hello world";
return p; //错误代码
}
int main()
{
char* str = NULL;
str = get_memory();
printf("%s",str); //打印出来的是随机字符串
printf("%c",*str); //打印出来的是固定的 h
return 0;
}

这段代码在 get_memory 中,犯了一个经典的错误。
返回了已经释放过的局部变量。
我们来仔细分析一下

入栈

内存空间可以看成线性的存储空间。
而栈处于程序的数据区,对应的是每一个函数的局部变量的存储空间。
程序执行到了main函数中第一条指令时,即建立了main函数的栈,
标志是cpu的 esp 寄存器和 ebp 寄存器,前者指向main 函数栈的栈顶,后者指向栈底。
具体如图1所示,图1是当执行到了char* str=NULL
图1

这段代码后的栈状态。计算机已经为指针 str分配了4个字节的空间,当然现在的内容是NULL,不指向任何内容。

需要注意的一点是,在X86平台上,栈的增长空间是由高位向低位增长的,而堆内存是由低位向高位增长的。
当前的栈还只是main函数的栈,局部变量只有一个char指针string,占了4个字节,esp指向栈顶。
当上面的程序调用 get_memory 函数时,就进入了新函数的栈空间,
期间为了能在新函数运行结束后正确返回main函数,需要保护好调用现场。
我们的程序中需要保护的就是就是一个ebp地址和一个main函数中断执行的执行点,亦即返回地址,按照C函数调用管理,先入栈的是返回地址,其后才是ebp指针指向的地址。ebp入栈后的main函数栈如图2所示:

此时,只是返回地址和ebp指针入栈,函数尚未进入get_memory()函数。但esp栈顶指针已经指向了新的地址,main函数的栈 空间也随之增大。 真正进入get_memory函数,并且执行了char p[]=”hello world” 语句之后的栈空间如图3所示:

此时,已经进入了get_memory函数的栈,所以ebp寄存器指向新函数栈的栈底,这个栈底是在进入新函数之前最后时刻的 esp所指向的地址。所以我们在查看汇编代码时,能看到所有函数的头两个指令都类似于:

1
2
pushl   %ebp
movl %esp, %ebp

都是先让ebp入栈,即图3中的old ebp,然后再ebp更新为新的esp,此时esp正好指向的是ebp的下一个地址。

新的ebp前一个位置存储的是old ebp,从新的ebp开始就是get_memory的栈空间了。可以看到新函数中的局部变量,数组p 分配到了12个字节存储”hello wolrd”。计算机通过调整esp的位置来分配了空间,这就是在栈上分配内存的原理。就是简单 的调整esp而已。

注意图中的eax寄存器指向了p数组的首地址,这是因为,eax在X86平台上充当着传递返回值的作用。get_momeory函数返回的 是p数组的首地址,自然eax存储的就是p数组首地址了。

出栈

函数退出的过程和进入的过程正好相反,也只有这样才能正确恢复中断状态。在本文中的例子就是,首先释放p数组空间,释 放的过程和分配过程一样,只需调整esp寄存器的值就可以了。释放掉局部变量后,esp就指向了old ebp了,然后执行pop ebp指令,就恢复了ebp在main函数中的值,即main函数的栈底地址。之后就可以获取到返回地址,jmp到这个地址,就从 get_memory函数中回到了main函数中。此时的栈状态如图4所示:

此时esp已经指向了string变量的下一个地址,esp之后的所有的空间此时都是被释放掉了的,但此时因为还没有被重新 分配,所以他们的值还是原来的值,并没有变化。而string变量是get_memory函数的返回值,它还是指在原来p数组的首 地址位置:0xCFFFFFF0。

小结

从上面的分析过程,可以看出函数在调用过程中,所有的局部变量都是在栈上分配的,一旦退出了函数,就被释放。这就 是C函数中局部变量作用域仅在函数内有效的原因。需要明了的是调用过程中的压栈,出栈次序:先进入的是返回地址,然 后是old ebp。

问题分析

%S 为什么是乱码

首先需要补充一点,在上文中小结中提到,调用一个函数时先入栈的是返回地址,实际上,比返回地址更先入栈的是调用 函数的参数。上面的get_memory函数没有参数,所以直接先入栈了返回地址。在有参数的函数调用时,实际是需要先入栈 参数的。而且,对C/C++函数而言,入栈次序是从右向左的,最右的参数最先入栈。

因为我们的程序下一个调用的是printf函数,这个函数是有参数的,而且在我们的程序中中是两个参数。我们先讨论第一种 调用方法,即printf(“%s”,string)这个调用。进入该函数后的栈空间如图5所示:

进入函数时,最右边的参数arg2先入栈,按照C函数的值传递特性,此时传入的是string的副本,即arg2也是一个地址,指 向0xCFFFFFF0。然后arg1入栈,接着是返回地址入栈。因为arg2是4个字节,arg1也是一个字符串常量的地址,也是4个字 节。可以看到,此时的0xCFFFFFF0地址已经被返回地址覆盖掉了,而这个地址正是上次调用时的数组p的起始位置,并且 main中的局部变量string和printf的第二个参数arg2都指向这个地址,但此时该地址中的的值已经不是’h’了,同样的,因 为printf要为其局部变量分配内存,hello world的12个字节全部被覆写。

综上所述,printf在一进入的瞬间,哪怕不执行任何代码,原hello world的空间就被覆盖了,自然也不会得到正确的输 出。得到的全是随机的乱码。实际上也不能简单说是随机的,因为返回地址,printf的局部变量都是确定的,只是把这些 地址,局部变量都当成char输出时,肯定是乱码了,但肯定是确定的乱码。

%c 为什么始终是 h

再看第二个问题。同前者不一样的是,这次调用的第二个参数不是一个地址了,而是一个char。按照 值传递的特性,此时的arg2是string的一个拷贝,即arg2=string。而且,这个赋值过程发生在进入函数printf之前。 如图6所示:

图6显示了刚刚把printf的第2个参数arg2入栈后的情况,因为string是0xCFFFFFF0,且该位置此时还没有被覆写,所以 *string=’h’,而值传递后,argv2=’h’。这就是arg2压栈后的栈状态。

随后,继续把第一个参数压栈,再把函数返回地址压栈,此时压入了4+1+4=9个字节,esp到达了0xCFFFFFEF位置,如 图7所示:

图7显示的是,进入printf函数栈后的栈状态,此时虽然原来的0xCFFFFFF0位置开始的”hello world”被覆盖了,但argv2 值中所存的依然是’h’这个拷贝,所以,第二个程序最终输出的是’h’也就很正常了。

CATALOG
  1. 1. 结论
  2. 2.
    1. 2.1. 错误的小例子
    2. 2.2. 入栈
    3. 2.3. 出栈
    4. 2.4. 小结
  3. 3. 问题分析
    1. 3.1. %S 为什么是乱码
    2. 3.2. %c 为什么始终是 h